Skip to main content

copp\copp\copp2\dp2/
topp2_ra.rs

1//! Reachability-analysis solver for second-order time-optimal path parameterization.
2//!
3//! # Method identity
4//! This module implements **Reachability Analysis (RA)** for
5//! **Time-Optimal Path Parameterization (TOPP2)** and shares the same state-space
6//! conventions used by **Convex-Objective Path Parameterization (COPP2)** components.
7//!
8//! # Discrete variables (local notation)
9//! On a path grid `s[0..=n]`:
10//! - `a[k]` denotes $\dot{s}_k^2$;
11//! - backward intervals are `[a_min[k], a_max[k]]` from [`reach_set2_backward`](crate::solver::reach_set2::reach_set2_backward);
12//! - forward pass selects one feasible state per station, yielding the final profile `a`.
13//!
14//! # High-level pipeline
15//! 1. Build backward reachable intervals by calling [`reach_set2_backward`](crate::solver::reach_set2::reach_set2_backward).
16//! 2. Run forward clipping against local constraints and backward intervals.
17//! 3. Select maximal feasible `a[k]` at each stage to recover the time-optimal profile.
18
19use super::reach_set2::{ReachSet2Options, reach_set2_backward};
20use crate::copp::copp2::formulation::Topp2Problem;
21use crate::copp::{ApproxOrdering, approx_order};
22use crate::diag::{
23    CoppError, DebugVerboser, SilentVerboser, SummaryVerboser, TraceVerboser, Verboser, Verbosity,
24    format_duration_human,
25};
26use crate::math::numerical::{LpToleranceOptions, lp_1d};
27use core::f64;
28use itertools::izip;
29
30/// Solve TOPP2 with RA and return the profile $a(s)=\dot{s}^2$.
31///
32/// # Returns
33/// Returns `a` such that:
34/// - `a[0] = a_start`, `a[n] = a_final`, where `n = idx_s_final - idx_s_start`;
35/// - `a` is time-optimal under configured first-/second-order constraints.
36///
37/// The returned profile can be mapped to `t(s)` by
38/// [`s_to_t_topp2`](crate::solver::topp2_ra::s_to_t_topp2), then to sampled
39/// `s(t)` by [`t_to_s_topp2`](crate::solver::topp2_ra::t_to_s_topp2).
40///
41/// # Errors
42/// Returns [`CoppError`](crate::diag::CoppError) when backward reachable-set construction fails or when
43/// forward pass cannot maintain feasibility under constraints.
44///
45/// # Contract
46/// - station interval and boundary states must be valid for the given constraints;
47/// - `options` must contain valid tolerance settings.
48pub fn topp2_ra(problem: &Topp2Problem, options: &ReachSet2Options) -> Result<Vec<f64>, CoppError> {
49    match options.verbosity {
50        Verbosity::Silent => topp2_ra_core(problem, (options, SilentVerboser)),
51        Verbosity::Summary => topp2_ra_core(problem, (options, SummaryVerboser::new())),
52        Verbosity::Debug => topp2_ra_core(problem, (options, DebugVerboser::new())),
53        Verbosity::Trace => topp2_ra_core(problem, (options, TraceVerboser::new())),
54    }
55}
56
57/// Core implementation of Reachability Analysis for TOPP2 with layered verbosity logging.
58fn topp2_ra_core(
59    problem: &Topp2Problem,
60    options_verboser: (&ReachSet2Options, impl Verboser),
61) -> Result<Vec<f64>, CoppError> {
62    let (options, mut verboser) = options_verboser;
63    if verboser.is_enabled(Verbosity::Summary) {
64        verboser.record_start_time();
65        crate::verbosity_log!(
66            Verbosity::Summary,
67            "\ntopp2_ra started: {} <= idx_s <= {}, a_start = {}, a_final = {}.",
68            problem.idx_s_interval.0,
69            problem.idx_s_interval.1,
70            problem.a_boundary.0,
71            problem.a_boundary.1,
72        );
73    }
74
75    // Step 1. Compute the backward reachable set.
76    let reach_set = reach_set2_backward(problem, options).map_err(|e| {
77        if verboser.is_enabled(Verbosity::Debug) {
78            crate::verbosity_log!(Verbosity::Debug, "{e:?}");
79        } else if verboser.is_enabled(Verbosity::Summary) {
80            crate::verbosity_log!(
81                Verbosity::Summary,
82                "topp2_ra: failed while computing backward reachable set."
83            );
84        }
85        e
86    })?;
87    let a_max = &reach_set.a_max;
88    let a_min = &reach_set.a_min;
89
90    // Step 2. Forward pass to select the maximal feasible state at each grid point.
91    if verboser.is_enabled(Verbosity::Debug) {
92        crate::verbosity_log!(Verbosity::Debug, "Forward pass started.");
93    }
94
95    let (idx_s_start, idx_s_final) = problem.idx_s_interval;
96    let n = idx_s_final - idx_s_start;
97    let mut a = vec![0.0; n + 1];
98    let mut a_prev = problem.a_boundary.0;
99    *a.first_mut().unwrap() = a_prev;
100
101    let mut a_b = Vec::<(f64, f64, f64)>::with_capacity(2 * problem.constraints.acc_rows());
102    for (k, (a_curr, &a_max_curr_, &a_min_curr_)) in
103        izip!(a.iter_mut(), a_max, a_min).enumerate().skip(1)
104    {
105        let idx_s = idx_s_start + k;
106        if verboser.is_enabled(Verbosity::Trace) {
107            crate::verbosity_log!(
108                Verbosity::Trace,
109                "\tForward pass at k = {k} (idx_s = {idx_s}): backward interval {a_min_curr_} <= a[k] <= {a_max_curr_}, a_prev = {a_prev}."
110            );
111        }
112
113        a_b.clear();
114        problem
115            .constraints
116            .fill_acc_topp2::<true>(&mut a_b, idx_s - 1);
117        // a_b.0 * a[k] + a_b.1 * a[k-1] <= a_b.2
118        let (mut a_max_curr, mut a_min_curr) = lp_1d::<true>(
119            a_b.iter().map(|&coeffs| {
120                // coeffs.0 * a_curr  + coeffs.1* a_prev <= coeffs.2
121                // coeffs.0 * a_curr <= coeffs.2 - coeffs.1 * a_prev
122                (coeffs.0, coeffs.2 - coeffs.1 * a_prev)
123            }),
124            &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
125        );
126
127        if verboser.is_enabled(Verbosity::Trace) {
128            crate::verbosity_log!(
129                Verbosity::Trace,
130                "\t\tForward LP result before clipping: {a_min_curr} <= a[k] <= {a_max_curr}."
131            );
132        }
133
134        a_max_curr = a_max_curr.min(a_max_curr_);
135        a_min_curr = a_min_curr.max(a_min_curr_);
136        if verboser.is_enabled(Verbosity::Trace) {
137            crate::verbosity_log!(
138                Verbosity::Trace,
139                "\t\tAfter clipping with backward reachable set: {a_min_curr} <= a[k] <= {a_max_curr}."
140            );
141        }
142
143        if a_max_curr.is_nan()
144            || a_min_curr.is_nan()
145            || matches!(
146                approx_order(
147                    a_max_curr,
148                    a_min_curr,
149                    options.a_cmp_abs_tol,
150                    options.a_cmp_rel_tol,
151                ),
152                ApproxOrdering::Less
153            )
154        {
155            let err = CoppError::Infeasible(
156                "topp2_ra".into(),
157                format!(
158                    "The reachable set is empty at index {} during the forward pass where a_max = {}, a_min = {}",
159                    idx_s_start + k,
160                    a_max_curr,
161                    a_min_curr
162                ),
163            );
164            if verboser.is_enabled(Verbosity::Debug) {
165                crate::verbosity_log!(Verbosity::Debug, "{err:?}");
166            } else if verboser.is_enabled(Verbosity::Summary) {
167                crate::verbosity_log!(
168                    Verbosity::Summary,
169                    "topp2_ra: the forward pass failed at index {idx_s} due to infeasibility."
170                );
171            }
172            return Err(err);
173        }
174
175        if a_max_curr.is_infinite() {
176            let err = CoppError::Unbounded(
177                "topp2_ra".into(),
178                format!(
179                    "The reachable set is unbounded at index {} during the forward pass where a_max = {}",
180                    idx_s_start + k,
181                    a_max_curr
182                ),
183            );
184            if verboser.is_enabled(Verbosity::Debug) {
185                crate::verbosity_log!(Verbosity::Debug, "{err:?}");
186            } else if verboser.is_enabled(Verbosity::Summary) {
187                crate::verbosity_log!(
188                    Verbosity::Summary,
189                    "topp2_ra: the forward pass failed at index {idx_s} due to unboundedness."
190                );
191            }
192            return Err(err);
193        }
194
195        if verboser.is_enabled(Verbosity::Debug)
196            && matches!(
197                approx_order(
198                    a_max_curr,
199                    a_min_curr,
200                    options.a_cmp_abs_tol,
201                    options.a_cmp_rel_tol,
202                ),
203                ApproxOrdering::Equal
204            )
205        {
206            crate::verbosity_log!(
207                Verbosity::Debug,
208                "The one-step forward reachable set at k = {k} (idx_s = {idx_s}) is degenerate since a_max and a_min are approximately equal at {}.",
209                0.5 * (a_max_curr + a_min_curr)
210            );
211        }
212
213        a_prev = a_max_curr;
214        *a_curr = a_prev;
215
216        if verboser.is_enabled(Verbosity::Trace) {
217            crate::verbosity_log!(
218                Verbosity::Trace,
219                "\t\tSelected maximal feasible state: a[k] = {a_prev}."
220            );
221        }
222    }
223
224    if verboser.is_enabled(Verbosity::Summary) {
225        crate::verbosity_log!(
226            Verbosity::Summary,
227            "topp2_ra: total elapsed time = {}.\n",
228            format_duration_human(verboser.elapsed())
229        );
230    }
231
232    Ok(a)
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::copp::InterpolationMode;
239    use crate::copp::copp2::stable::basic::{
240        Topp2ProblemBuilder, a_to_b_topp2, s_to_t_topp2, t_to_s_topp2,
241    };
242    use crate::copp::copp2::stable::reach_set2::ReachSet2OptionsBuilder;
243    use crate::path::{
244        Path, SplineConfig, add_symmetric_axial_limits_for_test, lissajous_path_for_test,
245    };
246    use crate::robot::robot_core::Robot;
247    use nalgebra::DMatrix;
248    use std::time::{Duration, Instant};
249
250    #[test]
251    fn test_topp2_ra() -> Result<(), CoppError> {
252        run_test_topp2_ra_repeated(1, false)
253    }
254
255    #[test]
256    #[ignore = "bindings"]
257    fn test_topp2_ra_bindings_parity() -> Result<(), CoppError> {
258        let dim = 3;
259        let num_waypoints = 8;
260        let n: usize = 201;
261        let pi = std::f64::consts::PI;
262
263        let waypoints = DMatrix::<f64>::from_fn(dim, num_waypoints, |axis, j| {
264            let s = j as f64 / (num_waypoints - 1) as f64;
265            match axis {
266                0 => 0.20 * (2.0 * pi * s).sin(),
267                1 => 0.15 * (1.5 * pi * s).cos(),
268                2 => 0.10 * s * (1.0 - s),
269                _ => unreachable!("dimension is fixed to 3"),
270            }
271        });
272        let path = Path::from_waypoints(&waypoints, SplineConfig::default())?;
273        let s = DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64);
274
275        let mut robot = Robot::with_capacity(dim, n);
276        robot
277            .with_s(&s.as_view())?
278            .with_q_from_path_2nd(&path, 0, n)?
279            .with_q_from_path_3rd(&path, 0, n)?;
280        add_symmetric_axial_limits_for_test(&mut robot, 10.0, 50.0, Some(1000.0))?;
281
282        let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
283        let options = ReachSet2OptionsBuilder::new().build()?;
284        let a_profile = topp2_ra(&topp2_problem, &options)?;
285
286        let (t_final, t_s) = s_to_t_topp2(s.as_slice(), &a_profile, 0.0)?;
287        let s_t = t_to_s_topp2(
288            s.as_slice(),
289            &a_profile,
290            &t_s,
291            InterpolationMode::UniformTimeGrid(0.0, 1e-3, true),
292        )?;
293
294        crate::verbosity_log!(
295            Verbosity::Summary,
296            "TOPP2-RA Rust bindings parity test: t_final={:.17}, s_t.len={}",
297            t_final,
298            s_t.len()
299        );
300
301        Ok(())
302    }
303
304    /// Conditions: release, --include-ignored, CPU = Intel(R) Core(TM) Ultra 9 285K.
305    /// Average over 10000 experiments: tc_topp2_ra = 0.286978 ms, tc_interpolation = 0.002001 ms, t_final = 6.168578
306    #[test]
307    #[ignore = "slow"]
308    fn test_topp2_ra_robust() -> Result<(), CoppError> {
309        run_test_topp2_ra_repeated(10000, true)
310    }
311
312    fn run_test_topp2_ra_repeated(n_exp: usize, flag_print_step: bool) -> Result<(), CoppError> {
313        let mut tc_sum_ra = Duration::ZERO;
314        let mut tc_sum_interpolation = Duration::ZERO;
315        let mut t_final_sum = 0.0;
316
317        let options = ReachSet2OptionsBuilder::new()
318            .lp_feas_tol(1E-9)
319            .a_cmp_abs_tol(1E-9)
320            .a_cmp_rel_tol(1E-9)
321            .verbosity(Verbosity::Summary)
322            .build()?;
323
324        for i_exp in 0..n_exp {
325            let dim = 7;
326            let n: usize = 1000;
327            let mut robot = Robot::with_capacity(dim, n);
328
329            let mut rng = rand::rng();
330            let (s, path, _, _) = lissajous_path_for_test(dim, n, &mut rng).map_err(|e| {
331                CoppError::InvalidInput("lissajous_path_for_test".into(), e.to_string())
332            })?;
333            robot
334                .with_s(&s.as_view())?
335                .with_q_from_path_2nd(&path, 0, n)?;
336            add_symmetric_axial_limits_for_test(&mut robot, 1.0, 1.0, None)?;
337
338            let start = Instant::now();
339            let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
340            let a_profile = topp2_ra(&topp2_problem, &options)?;
341            let tc_topp2_ra = start.elapsed();
342
343            let b_profile = a_to_b_topp2(s.as_slice(), &a_profile)?;
344            assert!(
345                !izip!(
346                    s.as_slice().windows(2),
347                    a_profile.windows(2),
348                    b_profile.iter()
349                )
350                .any(|(s_pair, a_pair, b)| {
351                    let ds_double = 2.0 * (s_pair[1] - s_pair[0]);
352                    let db = (a_pair[1] - a_pair[0]) / ds_double;
353                    (*b - db).abs() > 1e-3
354                }),
355                "b_profile generation failed!"
356            );
357
358            let start = Instant::now();
359            let (t_final, t_s) = s_to_t_topp2(s.as_slice(), &a_profile, 0.0)?;
360            assert_eq!(t_s.len(), s.ncols());
361            let tc_interpolation = start.elapsed();
362            let s_t = t_to_s_topp2(
363                s.as_slice(),
364                &a_profile,
365                &t_s,
366                InterpolationMode::UniformTimeGrid(0.0, 1E-3, true),
367            )?;
368
369            tc_sum_ra += tc_topp2_ra;
370            tc_sum_interpolation += tc_interpolation;
371            t_final_sum += t_final;
372
373            if flag_print_step && ((i_exp + 1) % 100 == 0) {
374                crate::verbosity_log!(
375                    Verbosity::Summary,
376                    "Exp #{}: tc_topp2_ra = {:.4} ms, tc_interpolation = {:.4} ms, t_final = {:.4} s, s_t.len() = {}",
377                    i_exp + 1,
378                    tc_topp2_ra.as_secs_f64() * 1E3,
379                    tc_interpolation.as_secs_f64() * 1E3,
380                    t_final,
381                    s_t.len()
382                );
383            }
384        }
385
386        crate::verbosity_log!(
387            Verbosity::Summary,
388            "Average over {} experiments: tc_topp2_ra = {:.6} ms, tc_interpolation = {:.6} ms, t_final = {:.6}",
389            n_exp,
390            tc_sum_ra.as_secs_f64() * 1E3 / n_exp as f64,
391            tc_sum_interpolation.as_secs_f64() * 1E3 / n_exp as f64,
392            t_final_sum / n_exp as f64
393        );
394
395        Ok(())
396    }
397}